iT邦幫忙

2025 iThome 鐵人賽

DAY 4
0
Modern Web

Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站系列 第 4

DOM 操作與事件綁定 – 用 TS 操作 HTML 元素

  • 分享至 

  • xImage
  •  

今日目標

了解 DOM 與常用的節點操作:查找、內容、屬性、樣式、類別、資料屬性

會用 事件綁定(click、input)及 事件委派(event delegation)

在履歷網站加入三個互動功能:主題切換、技能分類篩選、照片切換

基礎概念
什麼是 DOM?

DOM(Document Object Model)把 HTML 文件轉成「節點(Node)樹」,你可以用 JavaScript/TypeScript 操作它:查找元素、改文字、改屬性、改樣式、監聽事件等等。

常用 API 速查(本篇會全部用到)

查找元素:querySelector、querySelectorAll

內容:textContent、innerHTML(本篇盡量用 textContent)

屬性:getAttribute、setAttribute、hasAttribute、removeAttribute

樣式與類別:classList.add/remove/toggle、style.xxx

自訂資料屬性:dataset(如 data-category)

事件:addEventListener('click', handler)、preventDefault()、stopPropagation()

事件委派:監聽父節點,靠 event.target 判斷實際點擊者

實作一:主題切換(亮/暗)
HTML(在 裡放一顆切換按鈕)
切換主題

建議 Day 2 的 CSS/SCSS 有用到顏色變數(或 root 變數),這裡只示範最小掛鉤:當 有 data-theme="dark" 就套用深色主題。

最小 CSS 掛鉤(節錄,供參考)
/* 你可以在 Day 2 的樣式檔中加入 */
:root {
--bg: #ffffff;
--fg: #2c3e50;
}
html[data-theme="dark"] {
--bg: #1f2937;
--fg: #f3f4f6;
}
body { background: var(--bg); color: var(--fg); }

TypeScript(在 main.ts)
// 主題切換:把狀態存在 與 localStorage
const htmlEl = document.documentElement;
const themeToggleBtn = document.querySelector('#theme-toggle');

function applyTheme(theme: 'light' | 'dark') {
htmlEl.setAttribute('data-theme', theme);
if (themeToggleBtn) {
themeToggleBtn.setAttribute('aria-pressed', String(theme === 'dark'));
themeToggleBtn.textContent = theme === 'dark' ? '切換為亮色' : '切換為暗色';
}
localStorage.setItem('theme', theme);
}

// 初始化:讀取上次選擇
const saved = (localStorage.getItem('theme') as 'light' | 'dark') || 'light';
applyTheme(saved);

// 綁定按鈕
themeToggleBtn?.addEventListener('click', () => {
const next = htmlEl.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
applyTheme(next as 'light' | 'dark');
});

實作二:技能分類篩選(全部/前端/後端/工具)
HTML(在 Skills 區塊增加篩選器與標籤上的 data-category)

TypeScript(事件委派處理整個篩選器)
type SkillCategory = 'all' | 'frontend' | 'backend' | 'tools';

const filters = document.querySelector('#skill-filters');
const skillList = document.querySelector('#skill-list');

function applySkillFilter(cat: SkillCategory) {
if (!skillList) return;
const items = Array.from(skillList.querySelectorAll('li'));
items.forEach(li => {
const c = (li.dataset.category || 'frontend') as SkillCategory;
li.style.display = (cat === 'all' || c === cat) ? '' : 'none';
});
}

filters?.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.matches('button[data-filter]')) {
const cat = target.getAttribute('data-filter') as SkillCategory;
// 視覺/a11y 狀態
filters.querySelectorAll('[aria-selected="true"]').forEach(el => el.setAttribute('aria-selected', 'false'));
target.setAttribute('aria-selected', 'true');
// 套用篩選
applySkillFilter(cat);
}
});

// 預設顯示全部
applySkillFilter('all');

實作三:照片切換(正式照/生活照)
HTML(在 About 區塊加入兩張圖的來源路徑)

這裡使用 data-alt-src 存放替代圖片路徑,日後在框架中也很好綁定。

TypeScript
const img = document.querySelector('#avatar');
const photoToggle = document.querySelector('#photo-toggle');

photoToggle?.addEventListener('click', () => {
if (!img) return;
const current = img.getAttribute('src') || '';
const altSrc = img.dataset.altSrc || '';
if (!altSrc) return;
// 交換 src 與 data-alt-src
img.setAttribute('src', altSrc);
img.dataset.altSrc = current;
// 同步替代文字(若兩張照性質不同)
const isFormal = /formal/.test(altSrc);
img.alt = isFormal ? 'Chiayu 的正式照片' : 'Chiayu 的生活照片';
});

成果

完成以上三段後,你的履歷網站具備:

主題切換:可記住使用者偏好(localStorage),重載仍生效。

技能篩選:以 data-category 為依據,不用改 HTML 結構就能擴充。

照片切換:一鍵切換兩張照片,實作了自訂資料屬性與屬性交換。

小心踩雷(常見誤用 → 正確作法)

直接用 innerHTML 填入未消毒字串
錯誤:

titleEl.innerHTML = userInput; // 可能造成 XSS

正確:

titleEl.textContent = userInput; // 文字內容請用 textContent

忘了考慮元素可能為 null
錯誤:

document.querySelector('#x')!.addEventListener('click', fn);

正確:

const el = document.querySelector('#x');
if (el) el.addEventListener('click', fn);

為每一個子元素都綁監聽,造成效能浪費
錯誤:

document.querySelectorAll('#skill-filters button')
.forEach(b => b.addEventListener('click', onClick));

正確:事件委派(監聽父元素)

filters?.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.matches('button[data-filter]')) { /* ... */ }
});

用行內樣式硬改所有外觀,導致樣式與行為耦合
錯誤:

el.style.display = 'none';
el.style.color = 'red';

正確:

el.classList.add('is-hidden'); // 把樣式定義在 CSS 中

濫用 dataset 當狀態倉庫
錯誤:

el.dataset.state = JSON.stringify({ aLotOfState: true }); // 難以維護

正確: dataset 存放輕量、與 DOM 強相關的設定值(如類別、替代路徑);複雜狀態請放 JS/TS 內部結構。

進一步練習(可選)

技能清單支援「關鍵字即時搜尋」(監聽 input 事件 → includes 過濾)

主題切換加上動畫過渡(CSS transition)

按下導覽列的錨點時,平滑滾動到目標區塊(scrollIntoView({ behavior: 'smooth' }))

下一步(Day 5 預告)

明天我們會做一個 原生 HTML/CSS/TS 的一頁式自我介紹頁(小專案),把 Day1–Day4 的知識整合起來,形成「無框架也能交付」的最小可用作品。
接著 Day 6 起,我們會把同樣的資訊架構搬進 Angular,正式開始框架實戰。


上一篇
Day 3 TypeScript 開始 – 讓履歷網站動起來
下一篇
Day 5 RWD 響應式網頁設計 – 讓自我介紹頁在各裝置都好看
系列文
Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言